家庭网络分层拓扑(二):一条策略路由如何让 VPN 网关人间蒸发
目录 / Step 导航
上一篇《家庭网络分层拓扑:从远端笔记本到双出口的完整链路》讲了这套拓扑该怎么搭。这一篇讲它怎么坏的——一个只错了一行、却让人绕了整整一晚的故障。
最讽刺的地方在于:上一篇里已经写对了那一行,落地时却没抄进去。 而上一篇紧接着的另一句解释,恰好让那一行看起来可以省。
文中所有公网 IP、商业服务商品牌、个人域名与主机名均已抹去,只保留可复用的结构。
一、TL;DR
VPN 客户端可以正常上网、可以访问家里任何一台内网机器,唯独访问不了 VPN 网关 172.x.y.1 本身——ping 不通,全端口静默。
排查过程中先后怀疑并证伪了:客户端防火墙、DSM 防火墙(把整个防火墙关掉照样不通)、"网关和 NAS 不是同一台机器"。
真正的根因是 NAS 上这张表:
ip rule: 1: from 172.x.y.0/24 lookup 100
table 100: default via 192.168.0.254 dev ovs_eth0 ← 只有这一条
NAS 回复 VPN 客户端时,回包的源地址是 172.x.y.1,它落在 172.x.y.0/24 里。 于是回包命中这条规则、去查 table 100、只匹配到那条 default,被送去了旁路网关。而旁路网关没有回 VPN 网段的路由,包就此消失。
请求进得来,回包出不去。修复是补回上一篇里就写着的那条回程路由:
ip route replace 172.x.y.0/24 dev tun0 src 172.x.y.1 table 100
二、症状
远端笔记本通过 OpenVPN 拨回家,拿到 172.x.y.2/24,网关 172.x.y.1。一切正常:
- 默认路由走隧道,出口 IP 是境外节点 ✅
- 内网
192.168.0.0/24全通,NAS 的 DSM、SSH、SMB 都能用 ✅ - 唯独
ping 172.x.y.1100% 丢包,22/80/443/5000/5001全部无响应 ❌
隧道本身健康得很——tun0 上已经跑了 1.4 GB。就是网关这一个地址,像不存在一样。
三、排查:三个被证伪的假设
这次排查真正的价值不在结论,而在排除的过程。我依次相信过三个假设,全是错的。
3.1 假设一:客户端侧的问题
第一反应总是查本机。全部排除:
| 检查项 | 结果 |
|---|---|
| tun0 状态 | UP,累计 RX 1.4 GB / TX 838 MB |
| 默认路由 | ip route get 8.8.8.8 → via 172.x.y.1 dev tun0 |
| 本机防火墙 | ufw-user-input 里有 iifname "tun0" accept,已放行 346 个包 |
| rp_filter | = 2(loose),不会丢反向路径 |
tcpdump -i tun0 确认 ICMP echo request 确实从客户端发出去了,零回包。问题在对端。
3.2 假设二:NAS 防火墙 —— 证伪
这是最自然的猜测,而且证据看起来严丝合缝:群晖 DSM 的防火墙规则是按网络界面分档的,VPN 接口那一栏如果没有放行规则就落到默认拒绝;而转发流量走 FORWARD + NAT,不受 INPUT 规则影响——完美解释了"能上网但访问不了网关本身"。
于是把整个防火墙关掉。
结果:一模一样,还是不通。
这个假设死得干干净净。后来上机验尸也确认了它从头到尾就是无辜的:
Chain INPUT (policy ACCEPT)
Chain FORWARD (policy ACCEPT)
Chain OUTPUT (policy ACCEPT)
Chain DOS_PROTECT (1 references)
DROP icmp -- ovs_eth0 * ... ← 全部限定 in ovs_eth0,tun0 流量根本不匹配
DROP tcp -- ovs_eth0 * ...
Chain MAILSERVER_PLUS (1 references)
DROP tcp -- * * multiport dports 5230,5252,8500:8507,... ← 只 DROP 邮件端口
三条主链全是 policy ACCEPT,两个自定义链一个只管 ovs_eth0、一个只管邮件端口。没有任何一条规则能碰到发往 172.x.y.1 的包。
教训一:一个假设能解释所有已知现象,不代表它是对的。 它只是还没被证伪而已。要证伪它,就得去做那个能区分真假的实验——这里就是"把防火墙整个关掉",而不是继续在规则里微调。
3.3 假设三:搞错了机器 —— 证伪
既然 NAS 的 LAN 地址 192.168.0.4 一切正常,而 172.x.y.1 全死,那会不会它俩根本不是同一台机器?也许 VPN 服务跑在别的设备上,我一直在错误的机器上关防火墙。
用 TTL 判定:
$ ping -c 2 -t 1 192.168.0.4
64 bytes from 192.168.0.4: icmp_seq=1 ttl=64 time=7.37 ms ← TTL=1 就到了
$ ping -c 1 192.168.0.244
64 bytes from 192.168.0.244: icmp_seq=1 ttl=63 time=12.2 ms ← 对照:后面还有一跳
TTL=1 的包穿不过任何路由器(路由器会把它减到 0 然后丢弃)。它能被 192.168.0.4 收到并回复,只有一种解释:这个地址就在隧道的对端本机上。假设三出局。
3.4 转折点一:filtered 还是 closed?
/dev/tcp 这种土办法只能告诉你"连不上",区分不了被丢弃和没人监听。nmap 可以:
$ nmap -Pn --reason -sS -p 22,80,443,5000,8080 172.x.y.1
22/tcp filtered ssh no-response
80/tcp filtered http no-response
443/tcp filtered https no-response
5000/tcp filtered upnp no-response
8080/tcp filtered http-proxy no-response
$ nmap -Pn --reason -sS -p 22,80,443,5000,8080 192.168.0.4 # 同一台机器
22/tcp open ssh syn-ack ttl 64
80/tcp open http syn-ack ttl 64
443/tcp open https syn-ack ttl 64
5000/tcp open upnp syn-ack ttl 64
8080/tcp closed http-proxy reset ttl 64 ← 注意这一格
192.168.0.4:8080 回了 RST。这说明内核在正常处理发往那个地址的包,没监听的端口就规规矩矩拒绝。
而 172.x.y.1 上,连 22/80/443/5000 这些已经证明开着的端口都零响应。sshd 和 DSM 都是 bind 在 0.0.0.0 上的,只要包带着一个本机地址进了内核,它们必然应答。
结论:包没进到 TCP 协议栈。
3.5 转折点二:在服务端抓包
到这一步只能上 NAS 了。同时 ping 两个地址,在 NAS 的 tun0 上抓:
$ tcpdump -nli tun0 "icmp or tcp port 5000"
172.x.y.2 > 172.x.y.1: ICMP echo request, seq 1 ← 到了
172.x.y.2 > 172.x.y.1: ICMP echo request, seq 2 ← 到了
172.x.y.2 > 172.x.y.1: ICMP echo request, seq 3 ← 到了,一个回包都没有
172.x.y.2.59692 > 172.x.y.1.5000: Flags [S] ← 到了
172.x.y.2.59692 > 172.x.y.1.5000: Flags [S] ← 到了,没有 SYN-ACK
172.x.y.2 > 192.168.0.4: ICMP echo request, seq 1
192.168.0.4 > 172.x.y.2: ICMP echo reply, seq 1 ← 同一接口,秒回
172.x.y.2.40308 > 192.168.0.4.5000: Flags [S]
192.168.0.4.5000 > 172.x.y.2: Flags [S.] ← SYN-ACK
请求包完好无损地到达了内核,OpenVPN 交付无误,iptables 是 ACCEPT,但内核一声不吭。
这一刻方向变了:问题不在入方向,在出方向。
3.6 定位:ip route get
Linux 的路由查找是可以直接问的。把三种情况并排一比,答案就跳出来了:
# 回包(源地址 = VPN 网关自己)
$ ip route get 172.x.y.2 from 172.x.y.1
172.x.y.2 from 172.x.y.1 via 192.168.0.254 dev ovs_eth0 table 100 ← 走局域网了!
# 对照:源地址 = NAS 的 LAN 地址
$ ip route get 172.x.y.2 from 192.168.0.4
172.x.y.2 from 192.168.0.4 dev tun0 ← 正常
# 对照:不指定源地址
$ ip route get 172.x.y.2
172.x.y.2 dev tun0 src 172.x.y.1 ← 正常
找到了。
四、根因
4.1 机制
上一篇里,为了让 VPN 客户端享受和家里设备一致的分流体验,做了源地址策略路由:凡是源地址属于 VPN 网段的流量,默认路由换成旁路网关。
问题在于,NAS 有两个地址:LAN 侧 192.168.0.4,以及 tun0 上的 172.x.y.1。当它回复一个发往 172.x.y.1 的请求时,回包的源地址必须是 172.x.y.1(否则四元组对不上)。而 172.x.y.1 就在 172.x.y.0/24 里——它命中了那条规则:
回包 src=172.x.y.1 dst=172.x.y.2
→ ip rule 1: from 172.x.y.0/24 ✓ 命中
→ 查 table 100
→ 表里只有 default via 192.168.0.254
→ 回包被从 ovs_eth0 发给旁路网关
→ 旁路网关没有回 172.x.y.0/24 的路由
→ 丢弃
再往深一层:策略路由表一旦被规则命中,就自成体系了。 main 表里那条 172.x.y.0/24 dev tun0 完全帮不上忙,因为查找压根不会走到 main。而 table 100 里只有一条默认路由——一张只有 default 的路由表,意味着"任何目的地址都往下一跳扔",包括那些本该直连的目的地。
4.2 真正的教训:文档是对的,落地漏了一行
这才是这次故障最值得记的部分。
上一篇原文给的命令是两条:
# 1. 独立路由表:VPN 客户端的"默认路由"指向旁路网关(而不是上游网关)
ip route add default via 192.168.0.254 dev <内网接口> table 100
ip route add 172.x.y.0/24 dev tun0 table 100 # 回程路由也要在这张表里
第二行就是修复本身。 它当时就写在那里,还带着注释。
而实际落地到 NAS 开机任务里的脚本,是这样的:
#!/bin/sh
sleep 30
ip route replace default via 192.168.0.254 table 100
ip rule show | grep -q "from 172.x.y.0/24 lookup 100" || ip rule add from 172.x.y.0/24 table 100
回程路由那一行没了。
为什么会漏?因为上一篇紧接着的解释是这么写的:
- 命中
from 172.x.y.0/24的(= 公司笔记本),默认路由被替换成"下一跳旁路网关"。- NAS 自己的流量源地址是内网地址,不命中,继续查 main 表直连国内——NAS 的下载、备份、内网服务完全不受影响。
这段话让人相信:table 100 是给客户端用的,NAS 自己永远不会查它。 既然如此,往一张"客户端专用表"里放一条 VPN 网段的路由,看起来就像是冗余——客户端本来就在那个网段里,它发给同网段的包根本不会走三层。于是抄写脚本时顺手省掉了。
问题是那句话不完整:NAS 自己的流量里,有一类的源地址不是内网地址,而是 172.x.y.1。这类流量恰好就是"所有对 VPN 客户端的应答"。
教训二:一条配置为什么存在,比这条配置是什么更重要。 上一篇写了"回程路由也要在这张表里",但没写"给谁回程"。注释解释了 What,没解释 Why,于是在落地时被当成冗余删掉了。凡是看起来冗余的配置,要么补上它存在的理由,要么它迟早会被某个人(包括三个月后的你自己)删掉。
教训三:策略路由表不会 fallback 到 main。 一张只有 default 的表会把所有流量都往下一跳扔,包括本机的应答。凡是这张表的使用者需要访问的直连网段,都得显式写进去。
4.3 为什么 192.168.0.4 一直是好的
因为它命中的是另一条规则(DSM 自己维护的):
7: from 192.168.0.4 lookup ovs_eth0-table
而 ovs_eth0-table 里只有 192.168.0.0/24,匹配不到 172.x.y.2。策略路由的规则链在"查表未命中"时会继续往下走,于是落到 32766: from all lookup main,main 表里有 172.x.y.0/24 dev tun0 —— 正确出隧道。
同一台机器,两个源地址,两条完全不同的命运。
4.4 最精彩的细节:它能主动发包,却收不了包
排查中期有个现象一度让我判断失误——172.x.y.1 能给客户端发 ICMP:
$ ping -t 1 8.8.8.8
From 172.x.y.1 icmp_seq=1 Time to live exceeded ← 它活着,而且能发包给我
TTL 耗尽时,NAS 用 172.x.y.1 作源地址发回了 time-exceeded,一路顺利到达客户端。既然它能发,为什么 echo reply 发不出来?
区别在于源地址是什么时候被绑定的:
-
ICMP 差错报文(time-exceeded):触发它的原包目的地是
8.8.8.8,不是本机。在icmp_errors_use_inbound_ifaddr=0(默认)下,内核先做路由查找、再从出接口挑源地址。查找时源地址是未指定的,from 172.x.y.0/24这条按源匹配的规则自然不命中 → 落到 main 表 → 出 tun0 → 然后才把源地址填成出接口的主地址172.x.y.1。✅ -
ICMP echo reply / TCP SYN-ACK:源地址在路由查找之前就被钉死了(必须等于请求的目的地址
172.x.y.1),于是规则命中 → table 100 → 走岔。❌
这正是 ip route get 172.x.y.2(不带 from)返回 dev tun0、而 ip route get 172.x.y.2 from 172.x.y.1 返回 via 192.168.0.254 的原因。
教训四:出入不对称是源地址策略路由的指纹。 一个地址"能主动发、不能被访问",几乎必然是出方向的源地址路由问题,而不是入方向的过滤问题。下次先跑
ip route get <对端> from <本机该地址>,能省几小时。
五、修复
table 100 必须补全直连路由:
ip route replace 172.x.y.0/24 dev tun0 src 172.x.y.1 table 100 # 关键
ip route replace 192.168.0.0/24 dev ovs_eth0 src 192.168.0.4 table 100 # 建议
ip route replace default via 192.168.0.254 dev ovs_eth0 table 100
第一条是修复本身。第二条是顺手优化:在此之前,VPN 客户端访问家里其他机器要先绕到旁路网关再拐回来(实测多一跳,回包 ttl=63),还要白白穿一遍透明代理的 netfilter 路径;加上直连路由后走直连。
完整的落地脚本(群晖「控制面板 → 任务计划 → 用户定义的脚本」,用户选 root):
#!/bin/sh
VPN_NET=172.x.y.0/24
TUN_IP=172.x.y.1
TUN_DEV=tun0
LAN_NET=192.168.0.0/24
LAN_IP=192.168.0.4
LAN_DEV=ovs_eth0
PROXY_GW=192.168.0.254
RT=100
PREF=1
# ---- 不依赖 tun0,立刻可做 ----
ip route replace default via "$PROXY_GW" dev "$LAN_DEV" table "$RT"
ip route replace "$LAN_NET" dev "$LAN_DEV" src "$LAN_IP" table "$RT"
# 显式指定 pref。不写的话由内核自动分配,那是运气不是设计。
ip rule list | grep -q "from ${VPN_NET}" ||
ip rule add from "$VPN_NET" table "$RT" pref "$PREF"
# ---- 依赖 tun0:等它真正带上地址,最多 60 秒 ----
# 不能用固定 sleep:早了 tun0 还没起,下面这条会失败;晚了纯属白等。
n=0
until ip addr show "$TUN_DEV" 2>/dev/null | grep -q "inet ${TUN_IP}/"; do
n=$((n + 1))
[ "$n" -ge 60 ] && exit 0
sleep 1
done
# 修复点:回程路由。少了它,NAS 从 172.x.y.1 发出的回包会命中上面那条 rule,
# 在 table 100 里只匹配到 default,被甩给旁路网关,再也回不到隧道。
ip route replace "$VPN_NET" dev "$TUN_DEV" src "$TUN_IP" table "$RT"
ip route flush cache
exit 0
相比最初那版,有几处值得单独说:
| 改动 | 原因 |
|---|---|
补回 172.x.y.0/24 dev tun0 |
本文的全部内容 |
sleep 30 → 轮询等 tun0 带上地址 |
固定 sleep 是赌运气:早了 ip route ... dev tun0 src 172.x.y.1 直接失败(源地址还不是本机地址),晚了白等。改成轮询后,脚本才具备"可反复执行"的前提 |
ip rule add 显式写 pref |
不写 pref 时由内核自动分配。我这次恰好拿到 1(排在其他规则之前),换个加载顺序落到别的位置,匹配行为就变了 |
补 exit 0 |
如果这脚本被用作 OpenVPN 的 route-up 钩子,返回非 0 会让 OpenVPN 直接退出 |
grep -q "from ${VPN_NET}" 只匹配源前缀 |
不匹配 lookup 部分。否则一旦给 table 100 在 /etc/iproute2/rt_tables 里起了名字,输出会从 lookup 100 变成 lookup <name>,幂等判断失效,重复执行会不断累积规则 |
验证:
$ ping -c 3 172.x.y.1
3 packets transmitted, 3 received, 0% packet loss
rtt min/avg/max/mdev = 8.150/8.496/9.049/0.395 ms
$ ip route get 172.x.y.2 from 172.x.y.1
172.x.y.2 from 172.x.y.1 dev tun0 table 100 ← 留在隧道里了
防火墙重新启用后复测,不受影响。
六、实测:route-up 在 server 模式下根本不触发
修好之后还剩一个问题:这条回程路由该挂在哪里才不会再丢?
NAS 上原本挂着一个 OpenVPN 的 route-up 钩子脚本,内容正是那条回程路由命令。如果它可靠,那就够了,不需要别的。但故障发生时它加的路由并不在表里——所以它到底有没有被执行过,必须实测。
实验设计
一次重启,同时回答两个问题:钩子有没有被调用、路由会不会自己回来。
- 给
route-up.sh插两条互相独立的埋点:写文件 +logger进 syslog。任何一条留下记录,就证明它被执行过。 - 删掉 table 100 里的回程路由。
- 重启 VPN 服务。
- 每秒采样一次 table 100,连续 150 秒,记录路由首次出现的精确秒数。
因为重启会掐断隧道(而我正是从这条隧道连进 NAS 的),整个流程写成脚本用 setsid nohup 脱离终端执行,结果落盘,等隧道恢复后再回来取。
结果
[01:21:15] del rc=0 ; table100 条数=2 ← 回程路由已删除
[01:21:15] >>> synopkg restart VPNCenter
restart package [VPNCenter] successfully
[01:22:21] <<< restart rc=0 ← 服务确实重启,openvpn 新 PID @ 01:22:00
[01:24:53] *** 150 秒内路由从未出现 ***
--- route-up 自证日志 --- (空)
--- syslog 中的埋点 --- (空)
两条独立埋点全部为空。而同一个脚本手工执行完全正常:
[01:19:46] route-up FIRED dev=tun0 ifconfig_local=172.x.y.1
[01:19:46] route-up ip-route rc=0
所以不是脚本坏了,是 OpenVPN 压根没有调用它。
原因大概率在于 --route-up 的语义是"路由添加完成后执行"。一个 --server 实例并不为自己添加客户端路由,这个钩子自然就没有触发点。文档里它看起来像个通用的"隧道起来后执行"钩子,实际上不是。
教训五:没验证过的兜底,比没有兜底更危险。 这个脚本躺在那儿好几天,看起来像一层保险,实际从未执行过一次。它唯一的作用是让人误以为问题已经被兜住了——包括写它的人自己。任何自动恢复机制,都必须真的触发一次给你看。
顺带排除的几种可能
| 可能性 | 排除方式 |
|---|---|
| DSM 开机任务补上的 | last_start_time 仍停在上次开机,期间没有重跑 |
| 有什么在轮询 | 删掉后静置 30 秒,路由始终没有回来 |
| 表号撞车(DSM 也在用 100) | /etc/iproute2/rt_tables 里只有 2/3/5/7/8,没有 100 |
| 别的脚本在写这张表 | 全盘 grep table 100、VPN 套件启停脚本、crontab,均无结果 |
一个没能复现的矛盾
诚实记录:中间还有一轮 5 秒粒度的测试,路由曾在重启后约 28 秒出现过一次。上面这轮 1 秒粒度、150 秒的严格测试没能复现,上表里的几种可能也都排除了,我没查出那次是谁加的。
我按否定结论定性——它更保守,也和更严格的那次测试一致。但把这个矛盾记在这里,因为一份只写"结论很干净"的 RCA 是不可信的。
工程结论
route-up 这条路走不通,所以:
- VPN 服务重启会销毁重建 tun0,Linux 内核在网卡消失时会清掉所有引用它的路由——自定义表也不例外。
- 群晖的「触发的任务」只有开机/关机两种事件,没有"服务启动"钩子。
- 因此"开机跑一次"不够:只要在面板里改一次 VPN 设置触发重启,回程路由就没了,一直到下次重启 NAS 或手动补一次为止。
唯一的自愈手段是轮询:把第五节那个幂等脚本再注册一个「计划的任务 → 每天,重复间隔 5 分钟」。脚本本身设计成可反复执行就是为了这个。
那个从未执行过的 route-up.sh,连同配置里的 route-up 和 script-security 2 两行,已经一并删掉了。留着只会让下一个排查的人(很可能就是几个月后的自己)以为有兜底。
七、可复用的排查清单
按这个顺序走,大部分"某个地址访问不了"的问题都能收敛:
# 1. 包出去了吗?(客户端)
tcpdump -ni <出接口> host <目标>
# 2. 被丢弃还是没人监听?filtered vs closed 是完全不同的两件事
nmap -Pn --reason -sS -p <ports> <目标>
# 3. 目标和你以为的是同一台机器吗?TTL=1 打不穿任何路由器
ping -t 1 <目标>
# 4. 包到达对端内核了吗?(服务端)
tcpdump -ni <入接口> host <源>
# 到了却没回包 ⇒ 问题在出方向,别再查防火墙的 INPUT 了
# 5. 回包会往哪走?—— 决定性的一步
ip route get <对端> from <本机应答用的源地址>
ip route get <对端> # 对照:不指定源地址
ip rule show # 看有没有按源匹配的规则
ip route show table <被命中的表> # 看那张表是不是"残表"
第 5 步里"带 from 和不带 from 结果不一样"就是决定性证据。
八、结语
这个故障的技术根因只有一行路由,但它能活下来,靠的是四层叠加:
- 文档解释了 What,没解释 Why——"回程路由也要在这张表里"写了,但没写这条路由是给谁回程的。
- 旁边一句不完整的断言给了错误的安全感——"NAS 自己的流量源地址是内网地址,不命中",对主动流量成立,对应答流量不成立。
- 落地脚本和设计文档没有对账——两条命令只抄了一条,而且没人复核。
- 兜底机制从未被验证过——那个
route-up钩子看起来能补回丢失的路由,实测证明它一次都没执行过。它没有修复任何问题,只是让人以为问题已经被修复了。
分层解耦是好设计,但每一层的边界条件都得自己守住。这次踩的坑,只有当一台机器同时是网关和主机时才会出现——它既要转发别人的包,又要用那个网关地址回自己的包,而策略路由只认源地址,分不清这两者。